WPFやWinUIで業務アプリを作るとき、 SQLite × MVVM は「軽量・高速・保守しやすい」鉄板構成です。 しかし、設計を誤るとViewModelがDBコードだらけになり、 数年後には誰も触れないアプリになってしまいます。
この記事では、SQLiteとMVVMを組み合わせたときの 責務分離・レイヤー構成・非同期・テスト容易性を重視した “長期運用前提”のアーキテクチャを解説します。
・SQLite × MVVM の理想的なレイヤー構成
・Repository / Unit of Work / サービス層の役割
・ViewModelからDB依存を消す方法
・非同期処理(async/await)とUIフリーズ対策
・バリデーション・エラーハンドリングの置き場所
・テストしやすいMVVM構成
1. SQLite × MVVMの理想構成
まずは全体像から整理します。
■ レイヤー構成(論理)
- View(WPF XAML)
- ViewModel(画面ロジック・状態管理)
- サービス層(ユースケース・業務ロジック)
- Repository / Unit of Work(DBアクセス抽象化)
- SQLite(Dapper / EF Core)
ポイントは、ViewModelがSQLiteを知らないこと。 ViewModelは「サービス層のメソッドを呼ぶだけ」にしておくと、 テスト・差し替え・将来のDB移行が圧倒的に楽になります。
2. Repository / Unit of Work の役割
SQLiteへの生SQLやDapper/EF Coreのコードは、 RepositoryとUnit of Workに閉じ込めます。
■ IUserRepository(例)
public interface IUserRepository
{
Task<IEnumerable<User>> GetAllAsync();
Task<User?> GetByIdAsync(int id);
Task AddAsync(User user);
Task UpdateAsync(User user);
Task DeleteAsync(int id);
}
■ IUnitOfWork
public interface IUnitOfWork : IAsyncDisposable
{
IUserRepository Users { get; }
Task CommitAsync();
Task RollbackAsync();
}
ViewModelはUnit of Workの存在すら知らず、 サービス層がUoWを使ってトランザクションをまとめる構造にします。
3. サービス層で「ユースケース」を表現する
サービス層は、画面から見た「やりたいこと」をそのままメソッドにします。
■ IUserService(例)
public interface IUserService
{
Task<IEnumerable<UserDto>> GetUsersAsync();
Task SaveUserAsync(UserDto dto);
Task DeleteUserAsync(int id);
}
■ 実装例
public class UserService : IUserService
{
private readonly IUnitOfWorkFactory _uowFactory;
public UserService(IUnitOfWorkFactory uowFactory)
=> _uowFactory = uowFactory;
public async Task<IEnumerable<UserDto>> GetUsersAsync()
{
await using var uow = _uowFactory.Create();
var users = await uow.Users.GetAllAsync();
return users.Select(u => new UserDto
{
Id = u.Id,
Name = u.Name,
Age = u.Age
});
}
public async Task SaveUserAsync(UserDto dto)
{
await using var uow = _uowFactory.Create();
if (dto.Id == 0)
{
await uow.Users.AddAsync(new User
{
Name = dto.Name,
Age = dto.Age
});
}
else
{
var user = await uow.Users.GetByIdAsync(dto.Id);
if (user is null) return;
user.Name = dto.Name;
user.Age = dto.Age;
await uow.Users.UpdateAsync(user);
}
await uow.CommitAsync();
}
}
ViewModelはこのサービスを呼ぶだけで、 トランザクション・DB・マッピングを意識しなくて済みます。
4. ViewModelの責務を「画面ロジック」に限定する
ViewModelは、画面の状態管理とコマンド実行に集中させます。
■ UserListViewModel の例
public class UserListViewModel : INotifyPropertyChanged
{
private readonly IUserService _service;
public ObservableCollection<UserDto> Users { get; } = new();
public ICommand LoadCommand { get; }
public ICommand SaveCommand { get; }
private UserDto? _selectedUser;
public UserDto? SelectedUser
{
get => _selectedUser;
set { _selectedUser = value; OnPropertyChanged(); }
}
public UserListViewModel(IUserService service)
{
_service = service;
LoadCommand = new AsyncRelayCommand(LoadAsync);
SaveCommand = new AsyncRelayCommand(SaveAsync);
}
private async Task LoadAsync()
{
var list = await _service.GetUsersAsync();
Users.Clear();
foreach (var u in list)
Users.Add(u);
}
private async Task SaveAsync()
{
if (SelectedUser is null) return;
await _service.SaveUserAsync(SelectedUser);
await LoadAsync();
}
// INotifyPropertyChanged 実装は省略
}
ここにはSQLite・Dapper・SQL文が一切出てこないのが理想です。
5. 非同期処理とUIフリーズ対策
SQLiteアクセスは必ずasync/awaitで行い、 UIスレッドをブロックしないようにします。
■ 非同期コマンド(AsyncRelayCommand)
public class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private bool _isExecuting;
public AsyncRelayCommand(Func<Task> execute)
=> _execute = execute;
public bool CanExecute(object? parameter) => !_isExecuting;
public async void Execute(object? parameter)
{
if (_isExecuting) return;
_isExecuting = true;
try { await _execute(); }
finally { _isExecuting = false; }
}
public event EventHandler? CanExecuteChanged;
}
これにより、DBが重くてもUIは固まらない構成になります。
6. バリデーションとエラーハンドリングの置き場所
バリデーションはViewModelとサービス層の両方に分けて考えます。
■ ViewModel側
- 入力チェック(必須・形式・範囲)
- INotifyDataErrorInfo / IDataErrorInfo でUIにエラー表示
■ サービス層側
- ビジネスルール(重複禁止・状態遷移など)
- DB整合性チェック
例外(SQLiteExceptionなど)はサービス層で捕捉し、 ViewModelには「ユーザー向けメッセージ」として返すと綺麗に分離できます。
7. テスト容易性を高めるポイント
この構成にしておくと、次のようなテストが簡単になります。
- ViewModelテスト:IUserServiceをモック化
- サービス層テスト:InMemory SQLite + 実Repository
- Repositoryテスト:InMemory SQLiteで実クエリ検証
特に、ViewModelがDBを知らないことが テスト容易性に直結します。
8. 業務アプリ向けベストプラクティス
- SQLiteはRepository/UoWに閉じ込める
- サービス層は「ユースケース」をそのままメソッドにする
- ViewModelはサービス層だけを呼ぶ
- DBアクセスはすべてasync/awaitで非同期化
- バリデーションはViewModel(入力)+サービス層(ビジネス)で分担
- テストはInMemory SQLiteとモックを組み合わせる
まとめ:SQLite × MVVMは“責務分離”がすべて
- ViewModelからSQLite依存を追い出すと、アプリ寿命が伸びる
- Repository / Unit of Work / サービス層でDBと業務ロジックを整理する
- 非同期・バリデーション・テストまで一貫した構成にすると、長期運用に強い
「とりあえず動く」MVVMから、「10年保守できる」MVVMへ。 この記事の構成をベースに、あなたのSQLite × MVVMアプリを 一段上のアーキテクチャに仕上げてみてください。